package org.geoatlas.pyramid.index;

import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import org.geoatlas.pyramid.util.CommonUtils;
import org.opengis.referencing.crs.CoordinateReferenceSystem;

import java.util.*;

/** A TileMatrixSubset(GridSubset) is a GridSet + a coverage area
 * @author: <a href="mailto:thread.zhou@gmail.com">Fuyi</a>
 * @time: 2024/5/10 14:43
 * @since: 1.0
 **/
public class TileMatrixSubset {

    private final TileMatrixSet matrixSet;

    // {level}{minx,miny,maxx,maxy,z}
    private final Map<Integer, MatrixCoverage> gridCoverageLevels;

    private final boolean fullGridSetCoverage;

    private final BoundingBox subSetExtent;

    private final Integer minCachedZoom;

    private final Integer maxCachedZoom;

    protected TileMatrixSubset(
            TileMatrixSet gridSet,
            Map<Integer, MatrixCoverage> coverages,
            BoundingBox originalExtent,
            boolean fullCoverage) {
        this(gridSet, coverages, originalExtent, fullCoverage, null, null);
    }

    public TileMatrixSubset(
            TileMatrixSet gridSet,
            Map<Integer, MatrixCoverage> coverages,
            BoundingBox originalExtent,
            boolean fullCoverage,
            Integer minCachedZoom,
            Integer maxCachedZoom) {
        this.matrixSet = gridSet;
        this.gridCoverageLevels = coverages;
        this.subSetExtent = originalExtent;
        this.fullGridSetCoverage = fullCoverage;
        this.minCachedZoom = minCachedZoom;
        this.maxCachedZoom = maxCachedZoom;
    }

    public TileMatrixSubset(TileMatrixSubset subSet) {
        this(
                subSet.matrixSet,
                new TreeMap<>(subSet.gridCoverageLevels),
                subSet.subSetExtent,
                subSet.fullGridSetCoverage,
                subSet.minCachedZoom,
                subSet.maxCachedZoom);
    }

    public BoundingBox boundsFromIndex(long[] tileIndex) {
        return getGridSet().boundsFromIndex(tileIndex);
    }

    /**
     * Finds the spatial bounding box of a rectangular group of tiles.
     *
     * @param rectangleExtent the rectangle of tiles. {minx, miny, maxx, maxy} in tile coordinates
     * @return the spatial bounding box in the coordinates of the SRS used by the GridSet
     */
    public BoundingBox boundsFromRectangle(long[] rectangleExtent) {
        return matrixSet.boundsFromRectangle(rectangleExtent);
    }

    public long[] closestIndex(BoundingBox tileBounds) throws MatrixMismatchException {
        return matrixSet.closestIndex(tileBounds);
    }

    public long[] closestRectangle(BoundingBox rectangleBounds) {
        return matrixSet.closestRectangle(rectangleBounds);
    }

    /**
     * Indicates whether this gridsubset coverage contains the given tile
     *
     * @param index the tile index to check for coverage inclusion
     * @return {@code true} if {@code index} is inside this grid subset's coverage, {@code false}
     *     otherwise
     */
    public boolean covers(long[] index) {
        final int level = (int) index[2];
        final long[] coverage = getCoverage(level);
        if (coverage == null) {
            return false;
        }

        if (index[0] >= coverage[0]
                && index[0] <= coverage[2]
                && index[1] >= coverage[1]
                && index[1] <= coverage[3]) {
            return true;
        }

        return false;
    }

    public void checkCoverage(long[] index) throws OutsideCoverageException {
        if (covers(index)) {
            return;
        }

        if (index[2] < getZoomStart() || index[2] > getZoomStop()) {
            throw new OutsideCoverageException(index, getZoomStart(), getZoomStop());
        }

        long[] coverage = getCoverage((int) index[2]);
        throw new OutsideCoverageException(index, coverage);
    }

    public void checkTileDimensions(int width, int height) throws TileDimensionsMismatchException {

        // FIXME: 2024/5/10
//        if (width != matrixSet.getTileWidth() || height != gridSet.getTileHeight()) {
//            throw new TileDimensionsMismatchException(
//                    width, height, gridSet.getTileWidth(), gridSet.getTileWidth());
//        }
    }

    public long[][] expandToMetaFactors(final long[][] coverages, final int[] metaFactors) {
        long[][] ret = CommonUtils.arrayDeepCopy(coverages);

        for (long[] cov : ret) {
            final int z = (int) cov[4];
            final TileMatrix grid = this.matrixSet.getMatrix(z);
            final long numTilesWide = grid.getMatrixWidth();
            final long numTilesHigh = grid.getMatrixHeight();

            cov[0] = cov[0] - (cov[0] % metaFactors[0]);
            cov[1] = cov[1] - (cov[1] % metaFactors[1]);

            cov[2] = cov[2] - (cov[2] % metaFactors[0]) + (metaFactors[0] - 1);
            if (cov[2] > numTilesWide) {
                cov[2] = numTilesWide;
            }

            cov[3] = cov[3] - (cov[3] % metaFactors[1]) + (metaFactors[1] - 1);
            if (cov[3] > numTilesHigh) {
                cov[3] = numTilesHigh;
            }
        }

        return ret;
    }

    public long[] getCoverage(int level) {
        MatrixCoverage matrixCoverage = gridCoverageLevels.get(Integer.valueOf(level));
        if (matrixCoverage == null) {
            return null;
        }
        long[] coverage = matrixCoverage.coverage.clone();
        return coverage;
    }

    public long[][] getCoverages() {
        long[][] ret = new long[gridCoverageLevels.size()][5];

        final int zoomStart = getZoomStart();
        final int zoomStop = getZoomStop();

        for (int level = zoomStart, i = 0; level <= zoomStop; level++, i++) {
            long[] cov = getCoverage(level);
            ret[i] = cov;
        }

        return ret;
    }

    // FIXME: 2024/5/10
    /** Convert pixel size to dots per inch */
//    public double getDotsPerInch() {
//        return (0.0254 / this.ma.getPixelSize());
//    }

    public BoundingBox getCoverageBounds(int level) {
        long[] coverage = getCoverage(level);
        return matrixSet.boundsFromRectangle(coverage);
    }

    // Returns the tightest rectangle that covers the data
    public long[] getCoverageBestFit() {
        int level;
        long[] cov = null;

        final int zoomStart = getZoomStart();
        final int zoomStop = getZoomStop();

        for (level = zoomStop; level > zoomStart; level--) {
            cov = getCoverage(level);

            if (cov[0] == cov[2] && cov[1] == cov[3]) {
                break;
            }
        }

        cov = getCoverage(level);

        return cov;
    }

    public BoundingBox getCoverageBestFitBounds() {
        return boundsFromRectangle(getCoverageBestFit());
    }

    public long[] getCoverageIntersection(long[] reqRectangle) {
        final int level = (int) reqRectangle[4];
        MatrixCoverage gridCov = gridCoverageLevels.get(Integer.valueOf(level));
        return gridCov.getIntersection(reqRectangle);
    }

    public long[][] getCoverageIntersections(BoundingBox reqBounds) {
        final int zoomStart = getZoomStart();
        final int zoomStop = getZoomStop();

        long[][] ret = new long[1 + zoomStop - zoomStart][5];

        for (int level = zoomStart; level <= zoomStop; level++) {
            ret[level - zoomStart] = getCoverageIntersection(level, reqBounds);
        }
        return ret;
    }

    /**
     * Find the area that covers the given rectangle with tiles from the subset.
     *
     * @param level integer zoom level at which to consider the tiles
     * @param reqBounds BoundingBox to try to cover.
     * @return Array of long, the rectangle in tile coordinates, {minx, miny, maxx, maxy}
     */
    public long[] getCoverageIntersection(int level, BoundingBox reqBounds) {
        long[] reqRectangle = matrixSet.closestRectangle(level, reqBounds);
        MatrixCoverage matrixCoverage = gridCoverageLevels.get(Integer.valueOf(level));
        return matrixCoverage.getIntersection(reqRectangle);
    }

    public long getGridIndex(String gridId) {

        final int zoomStart = getZoomStart();
        final int zoomStop = getZoomStop();

        for (int index = zoomStart; index <= zoomStop; index++) {
            if (matrixSet.getMatrix(index).getTitle().equals(gridId)) {
                return index;
            }
        }

        return -1L;
    }

    public String[] getGridNames() {
        List<String> ret = new ArrayList<>(gridCoverageLevels.size());

        final int zoomStart = getZoomStart();
        final int zoomStop = getZoomStop();
        for (int i = zoomStart; i <= zoomStop; i++) {
            ret.add(matrixSet.getMatrix(i).getTitle());
        }

        return ret.toArray(new String[ret.size()]);
    }

    public TileMatrixSet getGridSet() {
        return matrixSet;
    }

    public BoundingBox getGridSetBounds() {
        return matrixSet.getBounds();
    }

    public long getNumTilesWide(int zoomLevel) {
        return matrixSet.getMatrix(zoomLevel).getMatrixWidth();
    }

    public long getNumTilesHigh(int zoomLevel) {
        return matrixSet.getMatrix(zoomLevel).getMatrixHeight();
    }

    public String getName() {
        return matrixSet.getTitle();
    }

    public BoundingBox getOriginalExtent() {
        if (this.subSetExtent == null) {
            return matrixSet.getExtent();
        }
        return this.subSetExtent;
    }

    public double[] getResolutions() {
        double[] ret = new double[gridCoverageLevels.size()];

        final int zoomStart = getZoomStart();
        final int zoomStop = getZoomStop();

        for (int z = zoomStart, i = 0; z <= zoomStop; z++, i++) {
            TileMatrix grid = matrixSet.getMatrix(z);
            ret[i] = grid.getResolution();
        }

        return ret;
    }

    // TODO: this is specific to KML service, move it somewhere on the kml module
    public long[][] getSubGrid(long[] gridLoc) throws GeoAtlasPyramidException {
        final int firstLevel = getZoomStart();
        final int zoomStop = getZoomStop();
        int idx = (int) gridLoc[2];

        long[][] ret = {{-1, -1, -1}, {-1, -1, -1}, {-1, -1, -1}, {-1, -1, -1}};

        if ((idx - firstLevel + 1) <= zoomStop) {
            // Check whether this grid is doubling
            double resolutionCheck =
                    matrixSet.getMatrix(idx).getResolution() / 2
                            - matrixSet.getMatrix(idx + 1).getResolution();

            if (Math.abs(resolutionCheck) > matrixSet.getMatrix(idx + 1).getResolution() * 0.025) {
                throw new GeoAtlasPyramidException(
                        "The resolution is not decreasing by a factor of two for "
                                + this.getName());
            } else {
                long[] coverage = getCoverage(idx + 1);

                long baseX = gridLoc[0] * 2;
                long baseY = gridLoc[1] * 2;
                long baseZ = idx + 1;

                long[] xOffset = {0, 1, 0, 1};
                long[] yOffset = {0, 0, 1, 1};

                for (int i = 0; i < 4; i++) {
                    if (baseX + xOffset[i] >= coverage[0]
                            && baseX + xOffset[i] <= coverage[2]
                            && baseY + yOffset[i] >= coverage[1]
                            && baseY + yOffset[i] <= coverage[3]) {

                        ret[i][0] = baseX + xOffset[i];
                        ret[i][1] = baseY + yOffset[i];
                        ret[i][2] = baseZ;
                    }
                }
            }
        }

        return ret;
    }

    // FIXME: 2024/5/10
    /** @return whether the scale is based on CRS84, even though it may not be */
//    public boolean getScaleWarning() {
//        return matrixSet.isScaleWarning();
//    }

    public CoordinateReferenceSystem getSRS() {
        return matrixSet.getCrs();
    }

    // FIXME: 2024/5/10
//    public int getTileHeight() {
//        return matrixSet.getTileHeight();
//    }

    // FIXME: 2024/5/10
//    public int getTileWidth() {
//        return gridSet.getTileWidth();
//    }

    /**
     * WMTS is indexed from top left hand corner. We will still return {minx,miny,maxx,maxy}, but
     * note that the y positions have been reversed
     */
    // TODO: this is specific to WMTS, move it somewhere on the wmts module
    // TODO: Does this need to be public?
    public long[][] getWMTSCoverages() {
        long[][] ret = new long[gridCoverageLevels.size()][4];

        final int zoomStop = getZoomStop();
        int zoomStart = getZoomStart();
        for (int i = zoomStart; i <= zoomStop; i++) {
            TileMatrix grid = matrixSet.getMatrix(i);
            long[] coverage = getCoverage(i);

            /*
             * Both internal and WMTS coordinates start at 0 and run to 1 less than the number of
             * tiles.  In internal coordinates, row 0 is the bottommost, and in WMTS it's the
             * topmost.  So subtract the row number from 1 less than the height to convert.
             */
//            long bottomRow = grid.getNumTilesHigh() - 1; // The WMST row number for the bottom row
            long bottomRow = grid.getMatrixHeight() - 1; // The WMST row number for the bottom row

            long[] cur = {
                    coverage[0], // minX
                    bottomRow - coverage[3], // minY
                    coverage[2], // maxX
                    bottomRow - coverage[1] // maxY
            };

            ret[i - zoomStart] = cur;
        }

        return ret;
    }

    public int getZoomStart() {
        Integer firstLevel = Collections.min(gridCoverageLevels.keySet());
        return firstLevel.intValue();
    }

    public int getZoomStop() {
        Integer maxLevel = Collections.max(gridCoverageLevels.keySet());
        return maxLevel.intValue();
    }

    public Integer getMinCachedZoom() {
        return minCachedZoom;
    }

    public Integer getMaxCachedZoom() {
        return maxCachedZoom;
    }

    /** Whether the Grid Subset equals or exceeds the extent of the Grid Set */
    public boolean fullGridSetCoverage() {
        return fullGridSetCoverage;
    }

    public boolean shouldCacheAtZoom(long zoom) {
        boolean shouldCache = true;
        if (minCachedZoom != null) {
            shouldCache = zoom >= minCachedZoom;
        }
        if (shouldCache && maxCachedZoom != null) {
            shouldCache = zoom <= maxCachedZoom;
        }
        return shouldCache;
    }

    @Override
    public String toString() {
        return ToStringBuilder.reflectionToString(this, ToStringStyle.SHORT_PREFIX_STYLE);
    }
}
